[#217] feat: tag-driven workflows — opt-in automation on tagged issues - #474
Conversation
Verdict
PR: [#474] · Author: Gianluca Carucci · Reviewer: independent reviewer · Date: 2026-08-30 · Story: US-217 · Type: feature Classification matrix — per dimension
Tier = max(assessed) = risk:red, unchanged. Cost = orange, unchanged. Review value confirms refinement; nothing raised. AssessmentsSecurity — Input validationVerdict: green — every value crossing the boundary ( Details
Security — Output handlingVerdict: green — the only outputs are a Security — AuthenticationVerdict: not applicable — the driver deliberately holds no credentials; the token stays in the adapter (ADR-024 §3). Security — AuthorizationVerdict: yellow — the boundary itself is right (untagged ⇒ never, eligibility before routing, no default workflow, all unit-tested), but the documented deployment of it weakens the concurrency half — see Major 3. Details
Security — Introduced vulnerabilitiesVerdict: green — 0 introduced, 0 pre-existing. CostVerdict: Architecture (Coupling)Verdict: not assessed — Bug fix — Red test before fixVerdict: not applicable — not a bug fix. (The DetailsFindings by severityCritical (must fix before merge)
Major (must fix before merge)
Minor (must fix before merge — same bar as Major, just lower impact)
Questions (informational, never blocking)
Positive feedback
Functionality & requirements (AC coverage)
Testing & quality gates
Adoption compliance
Tech debt
Documentation
Performance & deployment
|
…ecord a crash owes Review round 1 on PR #474 — 9 findings, all in place. - Adapter shell: `if [ -n "$record" ]` (a trailing `&&` under `bash -e` reddened the job on every untagged label edit) and `shell: bash` (GitHub's implicit shell has no pipefail, so a HALT behind `| tee` reported green). Executed, not grepped: `github-automation.test.ts` parses the shipped workflow and runs each step under the flags GitHub uses, against stub `pair`/`gh`. - The concurrency comment claimed the per-card lock guards cross-job runs. On ephemeral runners it cannot — fresh checkout, fresh working area. The group IS the guard there; the lock guards one working area (ADR-024). Corrected in the KB, the tutorial, commands.mdx and architecture.md. - A thrown run wrote no `end`: the trail stopped at `start` and read like a run still in flight. Now `outcome=crashed`, then rethrow. - A held lock reports the holder the acquirer named + how long it has held it; `LOCK_DIRECTORY` is exported instead of re-spelled at the call site. Stale locks are silent-forever otherwise; clearing them is in the KB pre-flight. - Confirmed and written down: only `start` is posted on the card, and an uninstalled workflow HALTs the whole board, not one tag. - Tutorial: seven sections / the seventh, line count dropped. Refs: #217
46901a6 to
69b6321
Compare
e8b183c to
5303b6c
Compare
Branch predated #451/#479/#434/#475/#473/#466: restoring those files to main reverted run/ adapter, US-479 loop text, repo-citation gate, skill counts, review-identity scenario. Re-applied only #217 hunks via per-file 3way port (hash-verified), renamed #217-owned bare pair-run to pair-cli (staleness gate now flags them), fixed 2 missed occurrences + ADL chalk note pattern. Local: pair-cli 1998, conformance 4576, website 216, dev-tools 697, mirrors green. Refs: #217
|
PR: #474 · Reviewer: maintainer review · Date: 2026-09-14 · Story: #217 · Type: feature (automation surface: dispatcher + lock + audit + adapter) · Head: Major — open
Assessments
Previously reported findings — closed
Functionality, adoption, documentation
Verification
Human gate: after the Major is closed, |
Major (reported): comma-split allowed a label containing a comma to synthesize a second tag, enabling unauthorized automation. Fixed by adding lossless JSON array serialization: --card-tags '["auto-dev", "risk:green,with,commas"]' Backwards-compatible comma-split retained for legacy callers. Also: updated github-automation.md adapter to use toJson() for lossless serialization from GitHub Actions. Regression tests added to parser.test.ts (3 new cases). Refs: #217
Extracted parseCardTags and validateTags helpers to keep complexity <= 10. Refs: #217
Mirror was out of sync with dataset source after toJson() change. Refs: #217
Refs: #217
…ment (#476) * [#419] feat: mirrors:regenerate — local, deterministic mirror realignment Squashed rebase onto 36e87cf (post-#474/#473/#470). 16 commits -> 1. Risk: yellow (local deterministic command, no behavior change by default). Intended changes: 33 files (mirror realignment command + smoke test + ADLs + doc). Refs: #419 * [#419] fix: docs staleness — publish-pr desc, date bump, pair-cli update Refs: #419 * [#419] fix: add llms-index scripts + quality-gate integration Refs: #419 * [#419] fix: regenerate .pair/llms.txt after rebase Refs: #419 * [#419] feat: replace mirror-realign-command with generic hook system Replace hardcoded mirror-realign-command with generic Publish-PR Hooks mechanism in tech/automation.md. publish-pr now reads hooks from tech/automation.md (pre-publish, post-publish) instead of mirror-realign-command in way-of-working.md. Pair configures pre-publish: pnpm mirrors:regenerate in tech/automation.md. Refs: #419 * [#419] fix: repair failing build/format/smoke checks on PR #476 - build: restore pair-implement-batch engine + tests from main (the branch carried a stale rewrite missing runId/entryCapsules/models); restore the -w short form in the shared write-mode offender list - build: ship the missing mirror-realign asset (src/tools/mirror-realign.ts + build script + both committed .cjs copies + drift test); correct one unsatisfiable assertion to the documented subset semantics - format: trailing newline in package.json, prettier wrap in regenerate-mirrors.test.ts, underscore emphasis in way-of-working.md - smoke: restore the Review identity wiring dropped from publish-pr SKILL (adoption bullet, Phase 5 resolve step, Identity row, HALT/degradation entries) and the Review identity adoption line; revert the inconsistent generic-hooks redesign back to the mirror-realign-command design the branch tests and PR body describe * [#419] fix: cap dev-tools vitest workers to stop CI RPC-timeout flake The regenerate suite shells out per test (turbo builds + real CLI runs). Under turbo's package-parallel CI run the default worker count starves the vitest parent RPC (Timeout calling onTaskUpdate with all 720 tests green). Cap at 2 workers for this package only. * [#419] fix: serialize dev-tools vitest files to stop CI RPC-timeout flake * [#419] feat: implement generic Publish-PR Hooks mechanism (#419) - publish-pr skill: replace hardcoded mirror-realign-command with generic Publish-PR Hooks mechanism (pre-publish / post-publish) read from tech/automation.md - Phase 1: run pre-publish hooks (mirror regeneration) before quality gate - Phase 5: run post-publish hooks after gate, before review dispatch - Unsafe-tree HALT scoped to adoption-declared unsafe trees (mirror/add) - Pair's adoption declares pre-publish: pnpm mirrors:regenerate in tech/automation.md - Update tests and conformance guards for new hook-based design - Version bump to 0.9.0 * [#419] fix: singleFork vitest pool for dev-tools to stop CI RPC-timeout flake * [#419] fix: increase dev-tools vitest testTimeout to 180s to prevent CI RPC timeout * [#419] fix: switch dev-tools vitest pool to threads to avoid RPC timeout * [#419] fix: add Publish-PR Hooks to pair's automation.md --------- Co-authored-by: T <t@e.com>
PR Information
PR Title: [#217] feat: tag-driven workflows — opt-in automation on tagged issues
Story/Epic: #217 (epic #212 — Supervised automation)
Type: Feature
Priority: High
Assignee: @rucka
Labels:
risk:redSummary
What Changed
A team declares
tag ⇒ workflowin.pair/adoption/tech/automation.md(## Workflows), andpair run --card <id> --card-tags <list>becomes the entry point a trigger calls: it routes that card to the workflow its tag names, and does nothing at all on every card nobody tagged.## Workflowsschema + readerworkflow-mapping.ts· schema owned by the KB guideline, mirrored to the datasetdispatch.ts(decideDispatch): pure function of card + observed tags + policy + installed-skill probeparser.ts(--card,--card-tags),handler.tsdispatch-audit.ts,card-lock.tsgithub-automation.mdWhy This Change
Automation must be opt-in per card, not per run:
## Eligibility(#216) answers which cards an unattended run may pick up; nothing answered what runs on a card once a trigger fires on it. R4.4 · Spec G10 · D18, D21.Story Context
User Story: As a team, we want to declare a tag→workflow mapping in adoption, so that an unattended trigger routes each tagged card to the workflow its tag names — and does nothing on every card nobody tagged.
cli.e2e.test.tsBOARDrows 301/304 — real project dir, real lock, real audit fileBOARDrows 302/303/305 + the negative no-start/no-route assertionsDISPATCH-RECORD:stdout line the host adapter posts; start only, never skip/end## Workflows⇒ clean exit, no default workflowruns nothing at all when the project declares no mappingrouting-purity.test.ts— the executable form of the DoD lineChanges Made
Implementation Details
decideDispatchis told the card's labels; it never reads a tracker, holds no credentials, makes no network call. Adding a code host is a new thin adapter.--rootforpair-loop,--storyforpair-process-plan-tasks) and nothing displaces it:--root,--skilland--promptare all refused alongside--card, and the handler reads the dispatched card beforeconfig.scope.root.DISPATCHABLE_WORKFLOWS—pair-loop,pair-process-plan-tasks), a declaration of its own, asserted set-EQUAL to the guideline's catalog table. Two things do not put a skill in it, and each has its own refusal and its own message: knowing how it spells its scope (pair-next— a dispatch would take the card's lock and post a record for a run that only prints a recommendation), and being able to be scoped at all (pair-process-refine-story— see below).--autonomouswith nobody watching, holding the card's lock, on a card already carrying a publicDISPATCH-RECORD:comment. A workflow whose own steps require an explicit human decision has two outcomes there and both are worse than not running: a stall until the per-iteration timeout, or an agent that answers its own gate.pair-process-refine-storyis the concrete exclusion (its phase 0 is the R3.11 alignment gate); it keeps itsSKILL_PARAMETERSrow, because--skill pair-process-refine-story --root <card>is a legitimate hand-driven run. A conformance guard reads each catalogued workflow's own SKILL.md, so re-adding a row fails a test rather than shipping. ADR-024 item 8.## Workflows⇒ "no mapping declared", clean exit. Uninstalled workflow, uncatalogued workflow, unscopable workflow, or a multi-tag card with noPrecedence:⇒ HALT with an adoption-fix message, before eligibility and routing — whole board, deliberately.mkdirlock underworking_path, taken after every refusal and before anything spawns, released in afinally. A burst's second trigger is skipped, never queued, and the skip prints the holder's path and age.appendAuditLinethrows by design, so each of the three writes has a message an operator can act on: a crash writesoutcome=crashed; astartthat cannot be written is reported as a dispatch that never began, not as a crashed run; a skip — the commonest outcome on a board — names the card and states that nothing was spawned, instead of surfacing a bareEACCES.ubuntu-latesthas heard of the CLI, so the shipped job installs it (setup-node+npm i -g @foomakers/pair-cli) before the dispatch step; the engine and its credentials stay the adopter's step, stated in the pre-flight. The step invokespair-cli run—@foomakers/pair-clipublishes exactly one bin,pair-cli, and ADL 2026-08-25 forbids apairalias, so an earlier revision of this PR that installed the package and typedpair runshipped a step that wascommand not found/127 on every runner. That is now proven by a real install, not by a string match — see the Reviewer Guide.Files Changed
workflow-mapping.ts,dispatch.ts,dispatch-audit.ts,card-lock.ts(+ a test file each),routing-purity.test.ts,scripts/smoke-tests/scenarios/github-dispatch-adapter.sh, ADR-024, two ADLsparser.ts,handler.ts,invocation.ts,perimeter.ts,metadata.ts,automation-policy.ts,resolve-skill.ts,policy-sections.ts,cli.e2e.test.ts; KBautomation-policy.md/github-automation.md/README.md(dataset + mirror);architecture.md,way-of-working.md,llms.txt; docsadoption-files.mdx,commands.mdx,unattended-delivery.mdx,guidelines-catalog.mdx; conformanceautomation-eligibility.test.ts,github-automation.test.ts40 files · +4848 / −173.
Services to Release
apps/pair-cli:pair-cli rungains--card/--card-tagsand the whole dispatch path. Every existing invocation is byte-identical — the dispatch branch is entered only when--cardis passed.Testing
Test Coverage
cli.e2e.test.tsdrives five triggers at ONE real project directory through the command registry, with only the engine spawn injected — real lock, real appended audit file. TheBOARDfixture is the assertion set (spawned prompts, audit lines, negative checks,DISPATCH-RECORD:list, released locks all iterate it), plus the burst case, the no-mapping board and the--root-refused case.invocation.test.tsreads the skills' real## Argumentstables and the KB catalog table — the driver cannot spell an argument a skill does not declare, cannot dispatch a workflow the catalog does not name, and the catalog cannot name a workflow whose SKILL.md declares a human-judgment gate.github-automation.test.tsparses the yaml the guideline ships and RUNS eachrun:step under the flags GitHub documents. The stubbed binary's name is now read from the document (invokedCommand()), never hardcoded — a stub named after an assumption is what let a dead step pass — and one case asserts that name equals the sole bin key declared by BOTH producers of the published manifest (apps/pair-cli/package.jsonand thebin: { … }literal inpackage-manual.sh).scripts/smoke-tests/scenarios/github-dispatch-adapter.sh(inCI_TESTS,OFFLINE_SAFE=true) runs the REALpackage-manual.shandcreate-registry-tgz.sh,npm install --global --prefix <clean temp prefix>of the resulting tarball, then executes the dispatch step extracted from the shipped guideline underenv -iwith only that prefix, node and the system bins on PATH — over BOTH the dataset copy and the root mirror. It assertsbin/pair-cliexists,bin/pairdoes not, the exit is not 127, and the installed CLI's ownno mapping declaredline appears (proof the real binary ran, not merely that some name resolved). Required by ADL 2026-08-31 — review baseline and provisioned-artifact contract.Test Results
Pre-merge tiering: disabledinway-of-working.md, so the full suite is the CI-parity set regardless of therisk:redtag.Branch is merged up to
origin/mainate7d375bf(merge commit5460fc48; one.pair/llms.txtdecision-log conflict, resolved as the union of both sides).Re-review scope (please read before starting). Per ADL 2026-08-31, the baseline is
reviewedHead = e4b42cc8(the last completed review). A re-review verifies the previously open findings plusgit diff e4b42cc8...origin/feature/US-217-tag-driven-workflowsand the directly changed producer/consumer boundary — it is not a rescan of the accumulated PR.Deliberately out of scope, and already carded:
apps/website/content/docs/**still writes barepair <cmd>throughout (commands.mdxalone has ~40 lines). That mechanical rename, and thedocs-stalenessINVOCATION_PREFIXwidening that keeps it fixed, are story #449's per ADL 2026-08-25. Renaming part of it here would leave one page internally inconsistent and pre-empt that story; the guideline fixed in this PR is the one that ships an executable, copy-verbatim artifact.Testing Strategy
DISPATCH-RECORD:on stdout.--card-tags ""— data, not a malformed flag), eligible-but-unmapped, mapped-but-ineligible, two mapped tags with/withoutPrecedence:, trigger burst, lock released mid-probe, dangling lock symlink, a## Workflowssection pasted with its markdown fence.outcome=crashedthen rethrow, lock still released; crash + unwritable audit ⇒ one error carrying both messages, engine error ascause; unwritable audit on the START ⇒ "nothing was spawned", no false crash claim; unwritable audit on a SKIP ⇒ the card and "nothing was spawned", not a bare filesystem error.Quality Assurance
Review Areas
dispatch.ts— the order (mapping → eligibility → routing) and the HALTs.assertWorkflowsScopabledeliberately has two messages: an uncatalogued workflow is a mapping to change, an unscopable one is a catalog entry with no argument row — telling the first it needs an argument row sends a maintainer to add one that exists.invocation.ts—SKILL_PARAMETERSvsDISPATCHABLE_WORKFLOWS: two tables answering two different questions ("how does this skill spell its scope" vs "may a tag route a card here").pair-process-refine-storyis in the first and not the second, on purpose.handler.tsdriveDispatchedCard— lock → audit → drive → release ordering, thestartedflag, andrecordSkip/recordCrashkeeping their messages true about what did and did not happen.parser.tsresolveDispatch— what is refused alongside--card, and why--filteris not.automation-policy.md— the schema bullet, the catalog table and the "needs a human in the room" rule must state ONE rule; three conformance guards hold them together, one of them against the catalogued skills' own SKILL.md.Documentation
.pairmirror):automation-policy.md## Workflowsschema, HALT list, the workflow catalog with its "how the dispatched card reaches it" column and the § "A workflow that needs a human in the room is not mappable";github-automation.mdreference adapter (five steps — checkout, setup-node, install, dispatch, record) + pre-flight.concepts/adoption-files,reference/cli/commands,tutorials/unattended-delivery,reference/guidelines-catalog.node:fsdirectly, ADL — an empty--card-tagsis an observation.Risk Assessment
commands.mdxand ADR-024 — surfacing it only on the card carrying that tag would make the failure depend on which trigger fired firstReviewer Guide
Testing the Changes
Key Test Scenarios
pair run --card 217 --card-tags "auto-dev,risk:green" --dry-runagainst a project declaringauto-dev ⇒ pair-loop— prints the route, spawns nothing.--card-tags ""— skipped cleanly, exit 0, skip logged.--card 217 --root 300; a mapping namingpair-nextorpair-process-review; a mapping namingpair-process-refine-story(installed, scopable, still refused — "the KB catalog does not name as mappable"); a## Workflowssection pasted inside its fence. Each refused with the file and the value to fix.Dependencies & Related Work
## Eligibility), pair-cli execution adapter: run a card on any engine (pi, opencode, claude -p) — portable fan-out, amends ADR-017 #451 (pair runexecution adapter), pair run / pair-loop do not forward the $approval signal to composed skills #464 ($approval)github-automation.md, per ADR-024 — the driver ships the agnostic core and the reference adapter, not a provisioned trigger.